/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Testing utilities for DOM related tests.
 */

goog.setTestOnly('goog.testing.dom');
goog.provide('goog.testing.dom');

goog.require('goog.array');
goog.require('goog.asserts');
goog.require('goog.dom');
goog.require('goog.dom.AbstractRange');
goog.require('goog.dom.InputType');
goog.require('goog.dom.NodeIterator');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.TagIterator');
goog.require('goog.dom.TagName');
goog.require('goog.dom.classlist');
goog.require('goog.dom.safe');
goog.require('goog.html.uncheckedconversions');
goog.require('goog.iter');
goog.require('goog.object');
goog.require('goog.string');
goog.require('goog.string.Const');
goog.require('goog.style');
goog.require('goog.testing.asserts');
goog.require('goog.userAgent');


/**
 * @return {!Node} A DIV node with a unique ID identifying the
 *     `END_TAG_MARKER_`.
 * @private
 */
goog.testing.dom.createEndTagMarker_ = function() {
  'use strict';
  var marker = goog.dom.createElement(goog.dom.TagName.DIV);
  marker.id = goog.getUid(marker);
  return marker;
};


/**
 * A unique object to use as an end tag marker.
 * @private {!Node}
 * @const
 */
goog.testing.dom.END_TAG_MARKER_ = goog.testing.dom.createEndTagMarker_();


/**
 * Tests if the given iterator over nodes matches the given Array of node
 * descriptors.  Throws an error if any match fails.
 * @param {!goog.iter.Iterator} it  An iterator over nodes.
 * @param {!Array<!Node|number|string>} array Array of node descriptors to match
 *     against.  Node descriptors can be any of the following:
 *         Node: Test if the two nodes are equal.
 *         number: Test node.nodeType == number.
 *         string starting with '#': Match the node's id with the text
 *             after "#".
 *         other string: Match the text node's contents.
 * @suppress {strictMissingProperties} charAt on union type
 */
goog.testing.dom.assertNodesMatch = function(it, array) {
  let i = 0;
  function checkNode(node) {
    'use strict';
    if (array.length <= i) {
      fail(
          'Got more nodes than expected: ' +
          goog.testing.dom.describeNode_(node));
    }
    const expected = array[i];

    if (goog.dom.isNodeLike(expected)) {
      assertEquals('Nodes should match at position ' + i, expected, node);
    } else if (typeof expected === 'number') {
      assertEquals(
          'Node types should match at position ' + i, expected, node.nodeType);
    } else if (expected.charAt(0) == '#') {
      assertEquals(
          'Expected element at position ' + i, goog.dom.NodeType.ELEMENT,
          node.nodeType);
      const expectedId = expected.slice(1);
      assertEquals('IDs should match at position ' + i, expectedId, node.id);

    } else {
      assertEquals(
          'Expected text node at position ' + i, goog.dom.NodeType.TEXT,
          node.nodeType);
      assertEquals(
          'Node contents should match at position ' + i, expected,
          node.nodeValue);
    }

    i++;
  }
  const iterator = goog.iter.toIterator(it);
  const iterable = /** @type {!Iterable<?>} */ ({
    [Symbol.iterator]: () => iterator,
  });
  for (const node of iterable) {
    checkNode(node);
  }
  assertEquals('Used entire match array', array.length, i);
};


/**
 * Exposes a node as a string.
 * @param {Node} node A node.
 * @return {string} A string representation of the node.
 */
goog.testing.dom.exposeNode = function(node) {
  'use strict';
  node = /** @type {!Element} */ (node);
  var result = node.nodeName || node.nodeValue;
  if (node.id) {
    result += '#' + node.id;
  }
  result += ':"' + (node.innerHTML || '') + '"';
  return result;
};


/**
 * Exposes the nodes of a range wrapper as a string.
 * @param {goog.dom.AbstractRange} range A range.
 * @return {string} A string representation of the range.
 */
goog.testing.dom.exposeRange = function(range) {
  'use strict';
  // This is deliberately not implemented as
  // goog.dom.AbstractRange.prototype.toString, because it is non-authoritative.
  // Two equivalent ranges may have very different exposeRange values, and
  // two different ranges may have equal exposeRange values.
  // (The mapping of ranges to DOM nodes/offsets is a many-to-many mapping).
  if (!range) {
    return 'null';
  }
  return goog.testing.dom.exposeNode(range.getStartNode()) + ':' +
      range.getStartOffset() + ' to ' +
      goog.testing.dom.exposeNode(range.getEndNode()) + ':' +
      range.getEndOffset();
};


/**
 * Determines if the current user agent matches the specified string.  Returns
 * false if the string does specify at least one user agent but does not match
 * the running agent.
 * @param {string} userAgents Space delimited string of user agents.
 * @return {boolean} Whether the user agent was matched.  Also true if no user
 *     agent was listed in the expectation string.
 * @private
 */
goog.testing.dom.checkUserAgents_ = function(userAgents) {
  'use strict';
  if (goog.string.startsWith(userAgents, '!')) {
    if (goog.string.contains(userAgents, ' ')) {
      throw new Error('Only a single negative user agent may be specified');
    }
    return !goog.userAgent[userAgents.slice(1)];
  }

  var agents = userAgents.split(' ');
  var hasUserAgent = false;
  for (var i = 0, len = agents.length; i < len; i++) {
    var cls = agents[i];
    if (cls in goog.userAgent) {
      hasUserAgent = true;
      if (goog.userAgent[cls]) {
        return true;
      }
    }
  }
  // If we got here, there was a user agent listed but we didn't match it.
  return !hasUserAgent;
};


/**
 * Map function that converts end tags to a specific object.
 * @param {Node} node The node to map.
 * @param {undefined} ignore Always undefined.
 * @param {!goog.iter.Iterator<Node>} iterator The iterator.
 * @return {Node} The resulting iteration item.
 * @private
 */
goog.testing.dom.endTagMap_ = function(node, ignore, iterator) {
  'use strict';
  goog.asserts.assertInstanceof(iterator, goog.dom.TagIterator);
  return iterator.isEndTag() ? goog.testing.dom.END_TAG_MARKER_ : node;
};


/**
 * Check if the given node is important.
 *
 * A node is important if it is
 *   - a non-empty text node; or,
 *   - or an element annotated to match on this user agent; or,
 *   - a non-annotated element
 *
 * @param {Node} node The node to test.
 * @return {boolean} Whether this node should be included for iteration.
 * @private
 */
goog.testing.dom.nodeFilter_ = function(node) {
  'use strict';
  if (node.nodeType == goog.dom.NodeType.TEXT) {
    // If a node is part of a string of text nodes and it has spaces in it,
    // we allow it since it's going to affect the merging of nodes done below.
    if (goog.string.isBreakingWhitespace(node.nodeValue) &&
        (!node.previousSibling ||
         node.previousSibling.nodeType != goog.dom.NodeType.TEXT) &&
        (!node.nextSibling ||
         node.nextSibling.nodeType != goog.dom.NodeType.TEXT)) {
      return false;
    }
    // Allow optional text to be specified as [[BROWSER1 BROWSER2]]Text
    var match = node.nodeValue.match(/^\[\[(.+)\]\]/);
    if (match) {
      return goog.testing.dom.checkUserAgents_(match[1]);
    }

    return true;
  }

  // This cast exists to preserve existing behaviour. It's risky, but fine as
  // long as we only access direct properties of `node`.
  var maybeElement = /** @type {!Element} */ (node);
  if (maybeElement.className && typeof maybeElement.className === 'string') {
    return goog.testing.dom.checkUserAgents_(maybeElement.className);
  }

  return true;
};


/**
 * Determines the text to match from the given node, removing browser
 * specification strings.
 * @param {Node} node The node expected to match.
 * @return {string} The text, stripped of browser specification strings.
 * @private
 */
goog.testing.dom.getExpectedText_ = function(node) {
  'use strict';
  // Strip off the browser specifications.
  return node.nodeValue.match(/^(\[\[.+\]\])?([\s\S]*)/)[2];
};


/**
 * Describes the given node.
 * @param {Node} node The node to describe.
 * @return {string} A description of the node.
 * @private
 */
goog.testing.dom.describeNode_ = function(node) {
  'use strict';
  if (node.nodeType == goog.dom.NodeType.TEXT) {
    return '[Text: ' + node.nodeValue + ']';
  } else {
    // We can't actually be sure this is an Element, but other code depends on
    // us pretending it is.
    node = /** @type {!Element} */ (node);
    return '<' + node.tagName + (node.id ? ' #' + node.id : '') + ' .../>';
  }
};


/**
 * Assert that the html in `actual` is substantially similar to
 * htmlPattern.  This method tests for the same set of styles, for the same
 * order of nodes, and the presence of attributes.  Breaking whitespace nodes
 * are ignored.  Elements can be
 * annotated with classnames corresponding to keys in goog.userAgent and will be
 * expected to show up in that user agent and expected not to show up in
 * others.
 * @param {string} htmlPattern The pattern to match.
 * @param {!Element} actual The element to check: its contents are matched
 *     against the HTML pattern.
 * @param {boolean=} opt_strictAttributes If false, attributes that appear in
 *     htmlPattern must be in actual, but actual can have attributes not
 *     present in htmlPattern.  If true, htmlPattern and actual must have the
 *     same set of attributes.  Default is false.
 */
goog.testing.dom.assertHtmlContentsMatch = function(
    htmlPattern, actual, opt_strictAttributes) {
  'use strict';
  var div = goog.dom.createDom(goog.dom.TagName.DIV);

  goog.dom.safe.setInnerHtml(
      div,
      goog.html.uncheckedconversions
          .safeHtmlFromStringKnownToSatisfyTypeContract(
              goog.string.Const.from('HTML is never attached to DOM'),
              htmlPattern));

  var errorSuffix =
      '\nExpected\n' + div.innerHTML + '\nActual\n' + actual.innerHTML;

  var actualIt = goog.iter.filter(
      goog.iter.map(
          new goog.dom.TagIterator(actual), goog.testing.dom.endTagMap_),
      goog.testing.dom.nodeFilter_);

  var expectedIt = goog.iter.filter(
      new goog.dom.NodeIterator(div), goog.testing.dom.nodeFilter_);

  var actualNode;
  var preIterated = false;
  var advanceActualNode = function() {
    'use strict';
    // If the iterator has already been advanced, don't advance it again.
    if (!preIterated) {
      actualNode = goog.iter.nextOrValue(actualIt, null);
    }
    preIterated = false;

    // Advance the iterator so long as it is return end tags.
    while (actualNode == goog.testing.dom.END_TAG_MARKER_) {
      actualNode = goog.iter.nextOrValue(actualIt, null);
    }
  };


  var number = 0;
  goog.iter.forEach(expectedIt, function(expectedNode) {
    'use strict';
    advanceActualNode();
    assertNotNull(
        'Finished actual HTML before finishing expected HTML at ' +
            'node number ' + number + ': ' +
            goog.testing.dom.describeNode_(expectedNode) + errorSuffix,
        actualNode);

    // Do no processing for expectedNode == div.
    if (expectedNode == div) {
      return;
    }

    assertEquals(
        'Should have the same node type, got ' +
            goog.testing.dom.describeNode_(actualNode) + ' but expected ' +
            goog.testing.dom.describeNode_(expectedNode) + '.' + errorSuffix,
        expectedNode.nodeType, actualNode.nodeType);

    if (expectedNode.nodeType == goog.dom.NodeType.ELEMENT) {
      var expectedElem = goog.asserts.assertElement(expectedNode);
      var actualElem = goog.asserts.assertElement(actualNode);

      assertEquals(
          'Tag names should match' + errorSuffix, expectedElem.tagName,
          actualElem.tagName);
      assertEquals(
          'Namespaces should match' + errorSuffix, expectedElem.namespaceURI,
          actualElem.namespaceURI);
      assertObjectEquals(
          'Should have same styles' + errorSuffix,
          goog.style.parseStyleAttribute(expectedElem.style.cssText),
          goog.style.parseStyleAttribute(actualElem.style.cssText));
      goog.testing.dom.assertAttributesEqual_(
          errorSuffix, expectedElem, actualElem, !!opt_strictAttributes);

      // Contents of template tags belong to a separate document and are not
      // iterated on by the current iterator, unless the browser is too old to
      // treat template tags differently. We recursively assert equality of the
      // two template document fragments.
      if (actualElem.tagName == goog.dom.TagName.TEMPLATE) {
        // IE throws if HTMLTemplateElement is referenced at runtime.
        actualElem = /** @type {HTMLTemplateElement} */ (actualElem);
        if (actualElem.content) {
          goog.testing.dom.assertHtmlMatches(
              expectedElem.innerHTML, actualElem.innerHTML,
              opt_strictAttributes);
        }
      }
    } else {
      // Concatenate text nodes until we reach a non text node.
      var actualText = actualNode.nodeValue;
      preIterated = true;
      while ((actualNode = goog.iter.nextOrValue(actualIt, null)) &&
             actualNode.nodeType == goog.dom.NodeType.TEXT) {
        actualText += actualNode.nodeValue;
      }

      var expectedText = goog.testing.dom.getExpectedText_(expectedNode);
      if ((actualText && !goog.string.isBreakingWhitespace(actualText)) ||
          (expectedText && !goog.string.isBreakingWhitespace(expectedText))) {
        var normalizedActual = actualText.replace(/\s+/g, ' ');
        var normalizedExpected = expectedText.replace(/\s+/g, ' ');

        assertEquals(
            'Text should match' + errorSuffix, normalizedExpected,
            normalizedActual);
      }
    }

    number++;
  });

  advanceActualNode();
  assertNull(
      'Finished expected HTML before finishing actual HTML' + errorSuffix,
      goog.iter.nextOrValue(actualIt, null));
};


/**
 * Assert that the html in `actual` is substantially similar to
 * htmlPattern.  This method tests for the same set of styles, and for the same
 * order of nodes.  Breaking whitespace nodes are ignored.  Elements can be
 * annotated with classnames corresponding to keys in goog.userAgent and will be
 * expected to show up in that user agent and expected not to show up in
 * others.
 * @param {string} htmlPattern The pattern to match.
 * @param {string} actual The html to check.
 * @param {boolean=} opt_strictAttributes If false, attributes that appear in
 *     htmlPattern must be in actual, but actual can have attributes not
 *     present in htmlPattern. If true, htmlPattern and actual must have the
 *     same set of attributes. Default is false.
 */
goog.testing.dom.assertHtmlMatches = function(
    htmlPattern, actual, opt_strictAttributes) {
  'use strict';
  var div = goog.dom.createDom(goog.dom.TagName.DIV);

  goog.dom.safe.setInnerHtml(
      div,
      goog.html.uncheckedconversions
          .safeHtmlFromStringKnownToSatisfyTypeContract(
              goog.string.Const.from('HTML is never attached to DOM'), actual));

  goog.testing.dom.assertHtmlContentsMatch(
      htmlPattern, div, opt_strictAttributes);
};


/**
 * Finds the first text node descendant of root with the given content.  Note
 * that this operates on a text node level, so if text nodes get split this
 * may not match the user visible text.  Using normalize() may help here.
 * @param {string|RegExp} textOrRegexp The text to find, or a regular
 *     expression to find a match of.
 * @param {Element} root The element to search in.
 * @return {?Node} The first text node that matches, or null if none is found.
 */
goog.testing.dom.findTextNode = function(textOrRegexp, root) {
  'use strict';
  var it = new goog.dom.NodeIterator(root);
  var ret = goog.iter.nextOrValue(goog.iter.filter(it, function(node) {
    'use strict';
    if (node.nodeType == goog.dom.NodeType.TEXT) {
      if (typeof textOrRegexp === 'string') {
        return node.nodeValue == textOrRegexp;
      } else {
        return !!node.nodeValue.match(textOrRegexp);
      }
    } else {
      return false;
    }
  }), null);
  return ret;
};


/**
 * Assert the end points of a range.
 *
 * Notice that "Are two ranges visually identical?" and "Do two ranges have
 * the same endpoint?" are independent questions. Two visually identical ranges
 * may have different endpoints. And two ranges with the same endpoints may
 * be visually different.
 *
 * @param {Node} start The expected start node.
 * @param {number} startOffset The expected start offset.
 * @param {Node} end The expected end node.
 * @param {number} endOffset The expected end offset.
 * @param {goog.dom.AbstractRange} range The actual range.
 */
goog.testing.dom.assertRangeEquals = function(
    start, startOffset, end, endOffset, range) {
  'use strict';
  assertEquals('Unexpected start node', start, range.getStartNode());
  assertEquals('Unexpected end node', end, range.getEndNode());
  assertEquals('Unexpected start offset', startOffset, range.getStartOffset());
  assertEquals('Unexpected end offset', endOffset, range.getEndOffset());
};


/**
 * Gets the value of a DOM attribute in deterministic way.
 * @param {!Element} node A node.
 * @param {string} name Attribute name.
 * @return {*} Attribute value.
 * @private
 */
goog.testing.dom.getAttributeValue_ = function(node, name) {
  'use strict';
  // These hacks avoid nondetermistic results in the following cases:
  // WebKit: Two radio buttons with the same name can't be checked at the same
  //      time, even if only one of them is in the document.
  if (goog.userAgent.WEBKIT && node.tagName == goog.dom.TagName.INPUT &&
      node['type'] == goog.dom.InputType.RADIO && name == 'checked') {
    return false;
  }

  // IE/Edge: cannot use node['src'] when the attribute contains HTTP
  // credentials. getAttribute works though.
  if ((goog.userAgent.IE || goog.userAgent.EDGE) && name == 'src') {
    return node.getAttribute(name);
  }

  // All browsers: some attributes return different values for getAttribute even
  // if the values are semantically equivalent. E.g. <div disabled=""> and
  // <div disabled="disabled"> should register as equal. We use node[name]
  // if it's available.
  return node[name] !== undefined &&
          typeof node.getAttribute(name) != typeof node[name] ?
      node[name] :
      node.getAttribute(name);
};


/**
 * Assert that the attributes of two Nodes are the same (ignoring any
 * instances of the style attribute).
 * @param {string} errorSuffix String to add to end of error messages.
 * @param {!Element} expectedElem The element whose attributes we are expecting.
 * @param {!Element} actualElem The element with the actual attributes.
 * @param {boolean} strictAttributes If false, attributes that appear in
 *     expectedNode must also be in actualNode, but actualNode can have
 *     attributes not present in expectedNode.  If true, expectedNode and
 *     actualNode must have the same set of attributes.
 * @private
 */
goog.testing.dom.assertAttributesEqual_ = function(
    errorSuffix, expectedElem, actualElem, strictAttributes) {
  'use strict';
  if (strictAttributes) {
    goog.testing.dom.compareClassAttribute_(expectedElem, actualElem);
  }

  var expectedAttributes = expectedElem.attributes;
  var actualAttributes = actualElem.attributes;

  for (var i = 0, len = expectedAttributes.length; i < len; i++) {
    var expectedName = expectedAttributes[i].name;
    var expectedValue =
        goog.testing.dom.getAttributeValue_(expectedElem, expectedName);

    var actualAttribute = actualAttributes[expectedName];
    var actualValue =
        goog.testing.dom.getAttributeValue_(actualElem, expectedName);

    // IE enumerates attribute names in the expected node that are not present,
    // causing an undefined actualAttribute.
    if (!expectedValue && !actualValue) {
      continue;
    }

    if (expectedName == 'id' && goog.userAgent.IE) {
      goog.testing.dom.compareIdAttributeForIe_(
          /** @type {string} */ (expectedValue), actualAttribute,
          strictAttributes, errorSuffix);
      continue;
    }

    if (goog.testing.dom.ignoreAttribute_(expectedName)) {
      continue;
    }

    assertNotUndefined(
        'Expected to find attribute with name ' + expectedName +
            ', in element ' + goog.testing.dom.describeNode_(actualElem) +
            errorSuffix,
        actualAttribute);
    assertEquals(
        'Expected attribute ' + expectedName + ' has a different value ' +
            errorSuffix,
        String(expectedValue), String(
                                   goog.testing.dom.getAttributeValue_(
                                       actualElem, actualAttribute.name)));
  }

  if (strictAttributes) {
    for (i = 0; i < actualAttributes.length; i++) {
      var actualName = actualAttributes[i].name;
      var actualAttribute = actualAttributes.getNamedItem(actualName);

      if (!actualAttribute || goog.testing.dom.ignoreAttribute_(actualName)) {
        continue;
      }

      assertNotUndefined(
          'Unexpected attribute with name ' + actualName + ' in element ' +
              goog.testing.dom.describeNode_(actualElem) + errorSuffix,
          expectedAttributes[actualName]);
    }
  }
};


/**
 * Assert the class attribute of actualElem is the same as the one in
 * expectedElem, ignoring classes that are useragents.
 * @param {!Element} expectedElem The DOM element whose class we expect.
 * @param {!Element} actualElem The DOM element with the actual class.
 * @private
 */
goog.testing.dom.compareClassAttribute_ = function(expectedElem, actualElem) {
  'use strict';
  var classes = goog.dom.classlist.get(expectedElem);

  var expectedClasses = [];
  for (var i = 0, len = classes.length; i < len; i++) {
    if (!(classes[i] in goog.userAgent)) {
      expectedClasses.push(classes[i]);
    }
  }
  expectedClasses.sort();

  var actualClasses = goog.array.toArray(goog.dom.classlist.get(actualElem));
  actualClasses.sort();

  assertArrayEquals(
      'Expected class was: ' + expectedClasses.join(' ') +
          ', but actual class was: ' + actualElem.className + ' in node ' +
          goog.testing.dom.describeNode_(actualElem),
      expectedClasses, actualClasses);
};


/**
 * Set of attributes IE adds to elements randomly.
 * @type {Object}
 * @private
 */
goog.testing.dom.BAD_IE_ATTRIBUTES_ = goog.object.createSet(
    'methods', 'CHECKED', 'dataFld', 'dataFormatAs', 'dataSrc');


/**
 * Whether to ignore the attribute.
 * @param {string} name Name of the attribute.
 * @return {boolean} True if the attribute should be ignored.
 * @private
 */
goog.testing.dom.ignoreAttribute_ = function(name) {
  'use strict';
  if (name == 'style' || name == 'class' || name == 'xmlns') {
    return true;
  }
  return goog.userAgent.IE && goog.testing.dom.BAD_IE_ATTRIBUTES_[name];
};


/**
 * Compare id attributes for IE.  In IE, if an element lacks an id attribute
 * in the original HTML, the element object will still have such an attribute,
 * but its value will be the empty string.
 * @param {string} expectedValue The expected value of the id attribute.
 * @param {Attr} actualAttribute The actual id attribute.
 * @param {boolean} strictAttributes Whether strict attribute checking should be
 *     done.
 * @param {string} errorSuffix String to append to error messages.
 * @private
 */
goog.testing.dom.compareIdAttributeForIe_ = function(
    expectedValue, actualAttribute, strictAttributes, errorSuffix) {
  'use strict';
  if (expectedValue === '') {
    if (strictAttributes) {
      assertTrue(
          'Unexpected attribute with name id in element ' + errorSuffix,
          actualAttribute.value == '');
    }
  } else {
    assertNotUndefined(
        'Expected to find attribute with name id, in element ' + errorSuffix,
        actualAttribute);
    assertNotEquals(
        'Expected to find attribute with name id, in element ' + errorSuffix,
        '', actualAttribute.value);
    assertEquals(
        'Expected attribute has a different value ' + errorSuffix,
        expectedValue, actualAttribute.value);
  }
};
